Repository navigation
Keep fast typing from being silently dropped - #358
Conversation
|
Hold — please don't merge yet. This changes collaborative sync correctness and an adversarial review of the latest commit is still in progress. I'll post the verdict here. 🤖 Generated with Claude Code |
|
Review: Looks good I checked the code adversarially against AGENTS.md (Runtime model, Collaboration failure traps). Correctness: holds up
Should the server change too? Not in this PR, but please open a follow-up
Minor
|
|
Thanks for the review. I addressed it in bbbbc16, and CI is green (validation, e2e, container).
|
|
The hold is lifted. The review points are addressed in bbbbc16, and CI is green. |
The plan service accepts at most 200 updates a second from one socket and ignores the rest without replying. The provider sent one update per Lexical update and only resent unacknowledged ones on the next open, so a fast burst lost its tail, and Yjs then held every later update from that client behind the missing one: nothing else typed in that session reached peers or survived a reload. The provider now merges updates made within 10 ms into one message, resends anything still unacknowledged after a full resend interval, and merges a replayed backlog into chunks under 64 KB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resends now double from 2 s up to 30 s, reset when an acknowledgement arrives, and stop after five sends. An update the server refused outright (implementation active, or an oversized update answered with a same-epoch reset) is not repeated; the next open still replays it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bbbbc16 to
f7cd066
Compare
Why
In the Decisions audit, a user typed four lines quickly. The last part ("d time to first edit.") stayed on their screen but never reached the other person, and it was gone after a reload. They got no notice. I reproduced this every time with the same Playwright typing against a local server.
The cause is the server's per-socket rate limit, not a Lexical or Yjs race.
submit()inapps/server/src/plan/service.tstakes at most 200 updates a second from one socket and drops the rest without a reply. The provider sent one update per Lexical update. It only resent unacknowledged updates on the nextplan:open, so a dropped update stayed in the outbox until a reconnect.It's worse than losing the burst's tail. Yjs holds back any update whose predecessor never arrived. So after one drop, nothing else that client typed in the session reached anyone. Typing " later" slowly, 2.5 s after a 260-character burst, never showed up for the peer, and it was missing after a reload. The server's log showed seq stopping at exactly 2 + 200.
What changed
All changes are in
packages/editor/src/provider.ts:implementation is active, or a same-epochplan:resetfor an update over the size limit. The next open still replays them.Provider flow
The provider groups rapid edits and retries an unacknowledged update up to five sends before waiting for the next document open.
Testing
bun test packages/editor/src/provider.test.ts: 19 pass. The new tests use a fake server that applies, acknowledges and drops updates. They cover:implementation is activeor a same-epoch resetThe burst and stranded-update tests failed before the fix.
bun test packages/editor apps/web: 988 pass on the first commit.bun test packages/editor: 491 pass after the follow-up.bun run types: clean.bun run ci: passes.bun run fix: no changes to these files.New
e2e/collab.e2e.tstest: Playwright types a burst with no delay, then slow text. It checks the peer and the durable source. Final rebased headf7cd0667passed hosted validation, E2E (including the burst-and-slow peer test), and container checks.Manual check against a fake-GitHub server with two browser contexts. Before: the burst-then-slow script showed the peer 201 of 261 characters and no " later", and a reload matched the peer. After: 261, " later" present, and the reload matched. The original audit typing script now converges for the peer and after a reload. Concurrent typing by two people with 0 to 6 ms key delays converged in all 8 runs, both before and after.
Not changed (findings)
syncYjsChangesToLexicaluses a non-discrete update taggedcollaboration, andsyncLexicalUpdateToYjsskips any update with that tag. So a local edit merged into the same pending state is never sent. A headless harness reproduces it when both happen synchronously. In a browser, keystrokes and socket messages are separate tasks, and Lexical commits each in a microtask. I found no path where they share a pending state. Every keystroke update I instrumented in the browser had no tags.room.applyaccepts those batches, and empty paragraphs don't serialize. This can't causeprotected-projection.rejectBatchrebuilds the room and broadcastsplan:reset. Each client clears its outbox and remounts, so unacknowledged edits from everyone in that 5 ms batch are lost.PlanEditorstoresstate.resetbut never shows it. That needs a design decision, so it's left for a follow-up.🤖 Generated with Claude Code